Skip to content

feat: add generic custom data source widget#610

Open
RC1140 wants to merge 1 commit intoimmichFrame:mainfrom
RC1140:feat/custom-data-source-widget
Open

feat: add generic custom data source widget#610
RC1140 wants to merge 1 commit intoimmichFrame:mainfrom
RC1140:feat/custom-data-source-widget

Conversation

@RC1140
Copy link
Copy Markdown

@RC1140 RC1140 commented Mar 23, 2026

Summary

Adds a configurable widget that fetches structured data from user-specified URLs and displays it on the photo frame. This enables displaying arbitrary external data (fitness stats, Home Assistant sensors, weather from custom sources, etc.) alongside photos.

  • Supports multiple data sources, each with independent refresh intervals and caching
  • URLs stay server-side (proxied via /api/CustomWidget) — only display config is exposed to the client
  • Follows existing widget patterns (weather/calendar/appointments) for consistency

Configuration

General:
  ShowCustomWidget: true
  CustomWidgetPosition: 'top-left'  # top-left, top-right, bottom-left, bottom-right
  CustomWidgetSources:
    - Url: 'http://my-service:3001/api/stats'
      RefreshMinutes: 10
    - Url: 'http://another-service/data'
      RefreshMinutes: 30

Expected response format from data sources

{
  "title": "My Stats",
  "items": [
    { "label": "Metric A", "value": "42", "secondary": "optional detail" },
    { "label": "Metric B", "value": "100" }
  ]
}

Changes

Backend (C# / ASP.NET Core):

  • CustomWidgetSourceConfig model for per-source URL + refresh interval
  • CustomWidgetData / CustomWidgetItem response models
  • ICustomWidgetService + CustomWidgetService with per-source caching
  • CustomWidgetController — proxies data (keeps URLs server-side)
  • Settings: ShowCustomWidget, CustomWidgetSources, CustomWidgetPosition
  • ClientSettingsDto exposes display config only

Frontend (Svelte 5):

  • New custom-widget.svelte component with configurable corner positioning
  • Integrated into home-page.svelte with conditional rendering
  • Regenerated oazapfts API client with new endpoint types

Test plan

  • Verify widget renders with valid data source URL
  • Verify widget hidden when ShowCustomWidget: false
  • Verify multiple data sources display correctly
  • Verify positioning works for all four corners
  • Verify graceful handling when data source is unreachable

Summary by CodeRabbit

  • New Features
    • Custom widget component now available on the home page for displaying external data from configured sources
    • Users can configure widget visibility, position, data source URLs, and refresh intervals
    • Automatic data refresh based on configured intervals per source

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 23, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 124a34d5-b27e-4acf-9f19-fd9911c5cd49

📥 Commits

Reviewing files that changed from the base of the PR and between 50b48a5 and 8066eee.

📒 Files selected for processing (15)
  • ImmichFrame.Core/Interfaces/ICustomWidgetService.cs
  • ImmichFrame.Core/Interfaces/IServerSettings.cs
  • ImmichFrame.Core/Models/CustomWidgetData.cs
  • ImmichFrame.Core/Models/CustomWidgetSourceConfig.cs
  • ImmichFrame.Core/Services/CustomWidgetService.cs
  • ImmichFrame.WebApi/Controllers/CustomWidgetController.cs
  • ImmichFrame.WebApi/Models/ClientSettingsDto.cs
  • ImmichFrame.WebApi/Models/ServerSettings.cs
  • ImmichFrame.WebApi/Program.cs
  • docker/Settings.example.json
  • docker/Settings.example.yml
  • immichFrame.Web/src/lib/components/elements/custom-widget.svelte
  • immichFrame.Web/src/lib/components/home-page/home-page.svelte
  • immichFrame.Web/src/lib/immichFrameApi.ts
  • openApi/swagger.json
✅ Files skipped from review due to trivial changes (8)
  • ImmichFrame.Core/Interfaces/ICustomWidgetService.cs
  • docker/Settings.example.yml
  • immichFrame.Web/src/lib/components/home-page/home-page.svelte
  • docker/Settings.example.json
  • ImmichFrame.WebApi/Program.cs
  • ImmichFrame.Core/Models/CustomWidgetSourceConfig.cs
  • openApi/swagger.json
  • ImmichFrame.Core/Models/CustomWidgetData.cs
🚧 Files skipped from review as they are similar to previous changes (5)
  • ImmichFrame.Core/Interfaces/IServerSettings.cs
  • ImmichFrame.WebApi/Controllers/CustomWidgetController.cs
  • immichFrame.Web/src/lib/components/elements/custom-widget.svelte
  • immichFrame.Web/src/lib/immichFrameApi.ts
  • ImmichFrame.Core/Services/CustomWidgetService.cs

📝 Walkthrough

Walkthrough

This PR adds a custom widget feature that enables users to display data fetched from external HTTP endpoints. It includes service-layer caching, API endpoints, configuration management, and a frontend Svelte component for rendering the widgets on the home page.

Changes

Cohort / File(s) Summary
Core Models and Interfaces
ImmichFrame.Core/Interfaces/ICustomWidgetService.cs, ImmichFrame.Core/Interfaces/IServerSettings.cs, ImmichFrame.Core/Models/CustomWidgetData.cs, ImmichFrame.Core/Models/CustomWidgetSourceConfig.cs
New service interface, data models, and configuration properties. IGeneralSettings extended with ShowCustomWidget, CustomWidgetSources, and CustomWidgetPosition fields.
Service Implementation
ImmichFrame.Core/Services/CustomWidgetService.cs
Implements custom widget data fetching with in-memory caching, HTTP client integration, JSON deserialization, and error logging. Cache uses URL-keyed entries with configurable TTL per source.
API Layer
ImmichFrame.WebApi/Controllers/CustomWidgetController.cs, ImmichFrame.WebApi/Models/ClientSettingsDto.cs, ImmichFrame.WebApi/Models/ServerSettings.cs
New authenticated API endpoint, extended DTOs with custom widget properties, and general settings configuration model updates.
Dependency Injection & API Spec
ImmichFrame.WebApi/Program.cs, openApi/swagger.json
Registered CustomWidgetService singleton; updated OpenAPI specification with new endpoint and schema definitions.
Configuration Examples
docker/Settings.example.json, docker/Settings.example.yml
Added example configuration showing ShowCustomWidget, CustomWidgetPosition, and CustomWidgetSources with sample URL and refresh interval.
Web Frontend
immichFrame.Web/src/lib/components/elements/custom-widget.svelte, immichFrame.Web/src/lib/components/home-page/home-page.svelte, immichFrame.Web/src/lib/immichFrameApi.ts
New Svelte component for rendering widgets with 60-second refresh interval; conditionally integrated into home page; API client extended with new types and getCustomWidgetData() function.

Sequence Diagram

sequenceDiagram
    participant FrontEnd as Frontend Component
    participant API as CustomWidget<br/>Controller
    participant Service as CustomWidget<br/>Service
    participant Cache as Memory Cache
    participant External as External<br/>HTTP Source

    FrontEnd->>API: GET /api/CustomWidget<br/>(clientIdentifier)
    API->>Service: GetCustomWidgetData()
    
    loop For each CustomWidgetSource
        Service->>Cache: Check cache<br/>(key: customwidget_{url})
        alt Cache Hit
            Cache-->>Service: Return CustomWidgetData
        else Cache Miss
            Service->>External: GET {source.Url}
            External-->>Service: JSON Response
            Service->>Service: Deserialize JSON<br/>→ CustomWidgetData
            Service->>Cache: Store with TTL<br/>(RefreshMinutes)
        end
        
        alt Success
            Service->>Service: Add to results
        else Error
            Service->>Service: Log error,<br/>continue
        end
    end
    
    Service-->>API: List<CustomWidgetData>
    API-->>FrontEnd: HTTP 200 + Data
    FrontEnd->>FrontEnd: Update widgetData<br/>Schedule next refresh
    FrontEnd->>FrontEnd: Re-render widgets
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested Labels

enhancement

Suggested Reviewers

  • JW-CH

Poem

🐰 A widget hops into view,
Fetching data fresh and new,
Cached and timed with sixty-second flair,
Custom sources everywhere!
The frame displays with graceful care.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add generic custom data source widget' directly and accurately summarizes the main change—adding a new configurable widget that fetches JSON data from user-specified sources and displays it on the photo frame.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@RC1140 RC1140 closed this Mar 23, 2026
Add a configurable widget that fetches structured data from user-specified
URLs and displays it on the photo frame. Supports multiple data sources,
each with independent cache TTL via CustomWidgetSources config.

Backend:
- CustomWidgetSourceConfig model for per-source URL + refresh interval
- CustomWidgetData/CustomWidgetItem response models
- ICustomWidgetService interface + CustomWidgetService with per-source caching
- CustomWidgetController proxying data (keeps URLs server-side)
- Settings: ShowCustomWidget, CustomWidgetSources, CustomWidgetPosition
- ClientSettingsDto exposes ShowCustomWidget and CustomWidgetPosition

Frontend:
- custom-widget.svelte component with configurable corner positioning
- Integrated into home-page.svelte with conditional render
- Regenerated oazapfts API client with new types
@RC1140 RC1140 reopened this Mar 23, 2026
@JW-CH
Copy link
Copy Markdown
Collaborator

JW-CH commented Mar 23, 2026

Do you have any example sources by chance to test?

@JW-CH JW-CH self-requested a review March 23, 2026 10:54
@RC1140
Copy link
Copy Markdown
Author

RC1140 commented Mar 23, 2026

@JW-CH Sorry I built that out myself will add some screenshots in a bit.

This is what it looks like when rendered using the webview
image

As mentioned the API endpoint I hit to get my strength data is a custom service and not really anything public, but returning the data in the structure below works for me atleast

 {
    "title": "IRON 3",
    "items": [
      { "label": "Last Workout", "value": "Full Body + Accessory", "secondary": "3h ago" },
      { "label": "Squat", "value": "30 kg x 5x5" },
      { "label": "Bench", "value": "10 kg x 5x5" },
      { "label": "Deadlift", "value": "30 kg x 1x5" },
      { "label": "OHP", "value": "10 kg x 5x5" },
      { "label": "Row", "value": "15 kg x 5x5" },
      { "label": "Week Volume", "value": "1,735 kg" },
      { "label": "Streak", "value": "4 weeks" },
      { "label": "Fitness (CTL)", "value": "21" },
      { "label": "Fatigue (ATL)", "value": "9" },
      { "label": "Resting HR", "value": "51 bpm" },
      { "label": "HRV", "value": "44" },
      { "label": "Rides", "value": "1 (60min)" }
    ]
  }

The idea here is that you have a title and then any number of rows returned which then gets rendered. You can add multiple sources if your data is not centralized

@RC1140 RC1140 force-pushed the feat/custom-data-source-widget branch from 50b48a5 to 8066eee Compare March 23, 2026 12:26
@RC1140
Copy link
Copy Markdown
Author

RC1140 commented Mar 23, 2026

Dont know how much extra work this would be but you can test it with a small python server like this

python3 -c "
import json
from http.server import HTTPServer, BaseHTTPRequestHandler

data = {'title':'IRON 3','items':[{'label':'Last Workout','value':'Full Body + Accessory','secondary':'3h ago'},{'label':'Squat','value':'30 kg x 5x5'},{'label':'Bench','value':'10 kg x 5x5'},{'label':'Deadlift','value':'30 kg x 1x5'},{'label':'OHP','value':'10 kg x 5x5'},{'label':'Row','value':'15 kg x 5x5'},{'label':'Week Volume','value':'1,735 kg'},{'label':'Streak','value':'4 weeks'},{'label':'Fitness (CTL)','value':'21'},{'label':'Fatigue (ATL)','value':'9'},{'label':'Resting HR','value':'51 bpm'},{'label':'HRV','value':'44'},{'label':'Rides','value':'1 (60min)'}]}

class H(BaseHTTPRequestHandler):
    def do_GET(self):
        body = json.dumps(data).encode()
        self.send_response(200)
        self.send_header('Content-Type', 'application/json')
        self.send_header('Content-Length', len(body))
        self.end_headers()
        self.wfile.write(body)
    def log_message(self, *a): pass

HTTPServer(('', 8080), H).serve_forever()
"

Paste that in a console and hit localhost:8080

Copy link
Copy Markdown
Collaborator

@JW-CH JW-CH left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey, thank you very much for your contribution! Really good implementation following the standards of the existing services.

I did an initial review with some first comments, I will also add another comment on the 'structure' of the expected Api-Data.

private readonly ILogger<AssetController> _logger;
private readonly ICustomWidgetService _customWidgetService;

public CustomWidgetController(ILogger<AssetController> logger, ICustomWidgetService customWidgetService)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong Logger-Type (AssetController)

@@ -0,0 +1,6 @@
using ImmichFrame.Core.Models;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Namespace is missing

using ImmichFrame.Core.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Namespace is missing

[ApiController]
[Route("api/[controller]")]
[Authorize]
public class CustomWidgetController : ControllerBase
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rename the controller to something like 'WidgetController' - Maybe in the Future there could be more Widgets maybe?

}

[HttpGet(Name = "GetCustomWidgetData")]
public async Task<List<CustomWidgetData>> GetCustomWidgetData(string clientIdentifier = "")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naming here is ok

private readonly IGeneralSettings _settings;
private readonly ILogger<CustomWidgetService> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions());
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you use the ApiCache here, see OpenWeatherMapService as reference

@JW-CH
Copy link
Copy Markdown
Collaborator

JW-CH commented Apr 10, 2026

I did a quick Claude research - posting the response after. I quite like the approach claude suggests and checked if this would be possible. Let me know what you think:

Clause answer:

The current design requires every external API to already return data in the CustomWidgetData shape. This means Home Assistant, OpenHAB, Grafana, or any other common self-hosted service would need a proxy/transformer in between — which adds friction for users.

A more open approach would be to add JSONPath mapping + header support directly to CustomWidgetSourceConfig, so ImmichFrame can talk to arbitrary REST APIs natively:

 public class CustomWidgetSourceConfig                                                                                                                                                                                                                                                                                                                                                                                                             
 {               
     public string Url { get; set; } = string.Empty;                                                                                                                                                                                                                                                                                                                                                                                               
     public int RefreshMinutes { get; set; } = 10;
     public Dictionary<string, string>? Headers { get; set; }  // e.g. Authorization: Bearer <token>                                                                                                                                                                                                                                                                                                                                               
     public string? Title { get; set; }                         // static widget title                                                                                                                                                                                                                                                                                                                                                             
     public string? ItemsPath { get; set; }                     // JSONPath to the items array, e.g. "$"
     public string? LabelPath { get; set; }                     // e.g. "$.attributes.friendly_name"                                                                                                                                                                                                                                                                                                                                               
     public string? ValuePath { get; set; }                     // e.g. "$.state"
     public string? SecondaryPath { get; set; }                 // e.g. "$.attributes.unit_of_measurement"                                                                                                                                                                                                                                                                                                                                         
 }               

A Home Assistant config would then look like this — no proxy needed:

  {                                                                                                                                                                                                                                                                                                                                                                                                                                                 
    "Url": "http://homeassistant:8123/api/states?domain=sensor",
    "Headers": { "Authorization": "Bearer <token>" },
    "Title": "Sensors",                                                                                                                                                                                                                                                                                                                                                                                                                             
    "ItemsPath": "$",
    "LabelPath": "$.attributes.friendly_name",                                                                                                                                                                                                                                                                                                                                                                                                      
    "ValuePath": "$.state",
    "SecondaryPath": "$.attributes.unit_of_measurement"
  }                                                                                                                                                                                                                                                                                                                                                                                                                                                 

If none of the path fields are set, the service falls back to deserializing the response as CustomWidgetData directly — so the existing generic use case stays fully supported and backwards-compatible.

The main trade-off is a JSONPath dependency (e.g. JsonPath.Net), but it's a small, well-maintained library and the flexibility gain is significant for the self-hosted audience this project targets.

@JW-CH
Copy link
Copy Markdown
Collaborator

JW-CH commented Apr 10, 2026

One thing the JSONPath mapping doesn't yet solve: selective filtering of items from an array response.

For example, querying Home Assistant's /api/states?domain=sensor returns every sensor. There's no way to say "only show these three." You'd have to configure one source entry per entity, which gets verbose quickly.

A small addition to CustomWidgetSourceConfig would fix this:

public List<string>? Include { get; set; }  // only render items whose resolved label matches

Config example:

  {
    "Url": "http://homeassistant:8123/api/states?domain=sensor",
    "Headers": { "Authorization": "Bearer <token>" },                                                                                                                                                                                                                                                                                                                                                                                               
    "Title": "Sensors",
    "ItemsPath": "$",                                                                                                                                                                                                                                                                                                                                                                                                                               
    "LabelPath": "$.attributes.friendly_name",
    "ValuePath": "$.state",
    "SecondaryPath": "$.attributes.unit_of_measurement",                                                                                                                                                                                                                                                                                                                                                                                            
    "Include": ["Living Room Temp", "Outdoor Humidity"]
  }

The service would filter the resolved items against the Include list after applying the JSONPath mappings. If Include is null or empty, all items are shown.


Single-entity endpoints are also worth considering. HA exposes individual entities directly:

GET /api/states/sensor.ikea_inspelning_livingroom_power

This returns a single object, not an array, so ItemsPath wouldn't apply. A clean way to handle this: if ItemsPath is not set, treat the entire response as a single item rather than trying to iterate over it.

{                                                                                                                                                                                                                                                                                                                                                                                                                                                 
    "Url": "http://homeassistant:8123/api/states/sensor.ikea_inspelning_livingroom_power",
    "Headers": { "Authorization": "Bearer <token>" },
    "Title": "Office",                                                                                                                                                                                                                                                                                                                                                                                                                              
    "LabelPath": "$.attributes.friendly_name",
    "ValuePath": "$.state",                                                                                                                                                                                                                                                                                                                                                                                                                         
    "SecondaryPath": "$.attributes.unit_of_measurement"
  }

This keeps the config intuitive: ItemsPath present → array response, ItemsPath absent → single object response.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants